Zhihao's Studio.

Build reusable visualization using d3.js and AngularJS

Word count: 1,808 / Reading time: 9 min
2014/11/15 Share

背景

前面两篇博客介绍了如何借助angular,让我们的可视化图能够更有模块化方便复用具有交互性,不过前面的博客只介绍了基本的原理以及最简单的图,在这篇博客中,我们来一起绘制一个相对复杂一些的热力图,最终的效果如下图。

这个热力图主要是用来展现维度与维度之间的相关性的,而相关性在0-1之间。千万不要小看这个热力图,图中的每一个元素,甚至每一个字都是绘制出来的,完成这个看似简单的热力图大概需要350行代码

具体实现

目标

我们想要用热力图的时候呢,希望能像内置的html DOM元素一样使用,比如

1
2
3
4
5
\<heatmap\>\</heatmap\>
//如果我们能指定一些属性就更好了,比如下面。
\<heatmap class="chart" data="data" dispatch="dispatch" options="options"\>\</heatmap\>

不要着急我们一步一步来完成。

create directive using Angular

前面的博客里已经介绍过了如何使用Angular来构造指令了,主要是借助angular.directive(‘heatmap’,function(){…})来完成的,主体框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
angular.module("heatmap", []()).directive("heatmap",
function() {
return {
restrict: "E",
replace: true,
scope: {
data: "=",
options: "=?",
dispatch: "=?"
},
transclude: false,
template: "\<div\>\</div\>",
link: function(scope, element) {
//暂时先不贴出来,后面详解。
}
}
})

前面已经提过了,link function类似于面向对象编程中的构造(constructor)函数,在个函数里,我们需要用d3.js来绘制所需的所有视觉元素。首先我们需要一些数据。

data

数据是维度与维度之间的相关性,因此,对于每一个数据,我们需要知道的有:维度1,维度2,维度1与维度2之间的相关性。

1
2
3
4
5
6
7
8
9
10
11
12
13
\<script\>
var s =["a","b","c","d","e","f","g","h","i",'j','k','l']();
var data = []();
for(var i=0;i\< s.length;i++)
for(var j=0;j\< s.length;j++)
{
data.push({'y':s[j](),'x':s[i](),"value":Math.random()})
}
\</script\>

这样我们得到的数据将会是这样的结构:
1
2
3
4
5
6
7
8
9
10
11
data = [
](){
"y": "a",
"x": “a”,
"value": 1.0
},
{
"y": "a",
"x": “b”,
"value": 0.73
},…]

options

为了实现options的可配置,我们需要预先提供一些已经定好的option。

1
2
3
4
5
6
7
8
9
10
11
var options = {
legend: true,
margin: { top: 50, right: 0, bottom: 100, left: 50 },
buckets: 9,
colors: ["#ffffd9", "#edf8b1", "#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#253494", "#081d58"](),
duration: 1000,
legendWidth: 0.3,
breaks: null
};

为了使得可以添加或者替换默认的options,我们借助angular.extend()函数将scope里传进来的options和默认的options进行合并,作为新的options。
1
2
3
if (scope.options) {
options = angular.extend(options, scope.options);
}

dispatch

通过dispatch预先定义一些操作,比如click,mouseover,mouse out,mouse move,在特定的情况下可以触发他们。代码这样写起来会比较优雅,显得非常的有条理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scope.dispatch = d3.dispatch("click", "mouseover", "mouseout", "mousemove");
$scope.$watch("dispatch", function() {
if ($scope.dispatch) {
$scope.dispatch.on("click", function(e) {
console.log(e);
});
$scope.dispatch.on("mouseover", function(e) {
return tooltip.text(e.y + ": " + e.x + " (" + e.value + ")").style("visibility", "visible");
})
$scope.dispatch.on("mousemove", function(e) {
return tooltip.style("top", (d3.event.pageY - 10) + "px").style("left", (d3.event.pageX + 10) + "px");
})
$scope.dispatch.on("mouseout", function(e) {
return tooltip.style("visibility", "hidden");
});
}
}, true);

render

前期的准备工作做得差不多了,接下来到了最重要的环节,绘制。说重要也重要,但其实这应该是d3.js的基本功了。无非就是控制每个元素的各种属性。值得注意的是,这里定义的svg不再是d3.select(‘body’)了,而是select(element【0】)。
相关代码以及注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
var render = function() {
var w = element[0]().offsetWidth;
var h = element[0]().offsetHeight;
var width = w - options.margin.left - options.margin.right;
var height = h - options.margin.top - options.margin.bottom;
//先要移除之前的记录
d3.select(element[0]()).select("svg").remove();
//svg的位置、大小
var svg = d3.select(element[0]()).append("svg")
.attr("width", width + options.margin.left + options.margin.right)
.attr("height", height + options.margin.top + options.margin.bottom)
.append("g")
.attr("transform", "translate(" + options.margin.left + "," + options.margin.top + ")");
//找到data中每一个维度的坐标,方便绘制
var xu = {};
var x = []();
var yu = {};
var y = []();
for (var i in scope.data) {
if (typeof(xu[scope.data[i]().x]) == "undefined") {
x.push(scope.data[i]().x);
}
xu[scope.data[i]().x] = 0;
if (typeof(yu[scope.data[i]().y]) == "undefined") {
y.push(scope.data[i]().y);
}
yu[scope.data[i]().y] = 0;
}
for (d in scope.data) {
scope.data[d]().xIndex = x.indexOf(scope.data[d]().x);
scope.data[d]().yIndex = y.indexOf(scope.data[d]().y);
}
//根据维度来决定热力图每一块的长宽,更dynamic
var xGridSize = Math.floor(width / x.length);
var yGridSize = Math.floor(height / y.length);
var legendElementWidth = Math.floor(width \* options.legendWidth / (options.buckets));
var legendElementHeight = height / 20;
// 绘制y轴旁的元素名
var yLabels = svg.selectAll(".yLabel")
.data(y)
.enter().append("text")
.text(function (d) { return d; })
.attr("x", 0)
.attr("y", function (d, i) { return i \* yGridSize; })
.style("text-anchor", "end")
.attr("transform", "translate(-6," + yGridSize / 1.5 + ")")
.attr("class", function (d, i) { return ("yLabel axis"); });
// 绘制x轴旁的元素名
var xLabels = svg.selectAll(".xLabel")
.data(x)
.enter().append("text")
.text(function(d) { return d; })
.attr("y", function(d, i) { return i \* xGridSize; })
.attr("x", 0)
.style("text-anchor", "start")
.attr("transform", "rotate(-90) translate(10, " + xGridSize / 2 + ")")
.attr("class", function(d, i) { return ("xLabel axis"); });
var colorScales = []();
if (options.breaks != null && options.breaks.length \> 0) {
for (b in options.colors) {
colorScales.push(d3.scale.quantile()
.domain([0, options.buckets - 1, d3.max(scope.data, function(d) { return d.value; })]())
.range(options.colors[b]()));
}
} else {
colorScales.push(d3.scale.quantile()
.domain([0, options.buckets - 1, d3.max(scope.data, function(d) { return d.value; })]())
.range(options.colors));
}
//绘制热力图中的最小cell单位,并制定颜色,动作
var cards = svg.selectAll(".square")
.data(scope.data);
cards.enter().append("rect")
.filter(function(d) { return d.value != null })
.attr("x", function(d) { return d.xIndex \* xGridSize; })
.attr("y", function(d) { return d.yIndex \* yGridSize; })
.attr("class", "square")
.attr("width", xGridSize)
.attr("height", yGridSize)
.on("click", function(d) { scope.dispatch.click(d); })
.on("mouseover", function(d) { scope.dispatch.mouseover(d); })
.on("mouseout", function(d) { scope.dispatch.mouseout(d); })
.on("mousemove", function(d) { scope.dispatch.mousemove(d); })
.style("fill", "#ffffff");
//增加动画效果 cards.transition().duration(options.duration).style("fill", function(d) {
if (options.customColors && options.customColors.hasOwnProperty(d.value)) {
return options.customColors[d.value]();
} else if (options.breaks != null && options.breaks.length \> 0) {
for (b in options.breaks) {
if (d.xIndex \< options.breaks[b]()) {
return colorScales[b]()(d.value);
}
}
return colorScales[options.breaks.length]()(d.value);
} else {
return colorScales[0]()(d.value);
}
});
cards.exit().remove();
if (options.legend) {
var legend = svg.selectAll(".legend")
.data([0]().concat(colorScales[0]().quantiles()).concat(d3.max(scope.data, function (d) { return d.value; })), function(d) { return d; });
legend.enter().append("g").attr("class", "legend");
//下面的参考数值对应的颜色
legend.append("rect")
.attr("x", function(d, i) { return legendElementWidth \* i; })
.attr("y", height \* 1.05)
.attr("width", legendElementWidth)
.attr("height", legendElementHeight)
.style("fill", function(d, i) { return options.colors[i](); })
.style("visibility", function(d, i) { return(i \< options.buckets ? "visible" : "hidden") });
//参考数值
legend.append("text")
.attr("class", "legendLabel")
.text(function(d,i) { return (0.1\*i+0.1).toFixed(1); })
.attr("x", function(d, i) { return legendElementWidth \* i; })
.attr("y", height \* 1.15)
.style("text-anchor", "middle");
legend.exit().remove();
}
};

增加响应

当数据变化,或webpage的窗口大小发生改变时,希望能够重新绘制热力图,这也是为什么我们上面将render函数定义为一个变量的原因。
监控数据的变化,可以借助angular的$watch函数来完成。为了有延时的效果,需要一个辅助函数,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//数据变化了
scope.$watch("data", function() {
render();
}, true);
//窗口大小改变
d3.select(window).on("resize", debounce(function() {
render();
}, 500));
//辅助函数,控制延时
var debounce = function(f, timeout) {
var id = -1;
return function() {
if (id \> -1) {
window.clearTimeout(id);
}
id = window.setTimeout(f, timeout);
}
};

CSS样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
\<style\>
.xLabel, .yLabel, .legendLabel {
font-size: 9px;
font-family: Verdana;
}
.tooltip {
font-size: 9px;
font-family: Verdana;
background-color: #333333;
padding: 5px;
color: #ffffff;
border-radius: 3px;
opacity: 0.7;
}
.chart {
width: 50%;
height: 600px;
background-color: #ffffff;
}
.square {
cursor: pointer;
}
\</style\>

Reference

  1. 《D3 on AngularJS》 https://leanpub.com/d3angularjs
  2. 《Build custom directives with AngularJS》 http://ngnewsletter.wpengine.com/?p=218
  3. 《AngularJS & D3: Directives for Visualizations》 https://www.youtube.com/watch?v=aqHBLS_6gF8
  4. pieterprovoost’s github https://github.com/pieterprovoost/heatmap
CATALOG
  1. 1. 背景
  2. 2. 具体实现
    1. 2.1. 目标
    2. 2.2. create directive using Angular
    3. 2.3. link function
      1. 2.3.1. data
      2. 2.3.2. options
      3. 2.3.3. dispatch
      4. 2.3.4. render
      5. 2.3.5. 增加响应
      6. 2.3.6. CSS样式表
  3. 3. Reference